Skip to content

[sandbox audit] Use each pooled sandbox's own capability token - #4832

Draft
Wauplin wants to merge 1 commit into
security/sandbox-docs-accuracyfrom
security/per-sandbox-capability-tokens
Draft

[sandbox audit] Use each pooled sandbox's own capability token#4832
Wauplin wants to merge 1 commit into
security/sandbox-docs-accuracyfrom
security/per-sandbox-capability-tokens

Conversation

@Wauplin

@Wauplin Wauplin commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

[sandbox audit] — PR 2 of 9 in this repo's stack; merge in order.
Previous: #4831 · Next: #4834
Review only the commits this PR adds on top of its base; bases collapse to main as the stack lands.

Server PR: huggingface/sandbox-server#18deploy the server first.

Why

The pool host's token authorized every sandbox on the host plus its management routes, and
it was the only credential the client had. So every per-sandbox call — and proxy_headers,
which is documented as the thing you hand to a browser or WebSocket client — offered authority
over the entire host.

sbx-server 0.6.0 mints a capability token per sandbox. This PR uses it.

What changed

  • _SandboxServer.request/stream take an optional sandbox_token that overrides
    X-Sandbox-Token for one request. The client-level header keeps carrying the host
    credential, which is still what management calls need.
  • Sandbox holds its own token and sends it on every scoped call — including the DELETE
    that kills a pooled sandbox.
  • SandboxPool.create() keeps the token from the create response.
  • Sandbox.connect("<host>.<id>") recovers it via GET /v1/sandboxes/{id}/token, so
    reattaching from another machine stays stateless (the property the whole nonce design
    exists for).
  • proxy_headers hands out the sandbox's token, not the host's.

Backwards compatibility

A host running an older server returns no token and has no recovery route. Those sandboxes
fall back to the host credential, so a pool booted before the server upgrade keeps
working
. The server also keeps accepting the host token on scoped routes during its own
compat window, so the ordering constraint is one-way: deploy the server first, then this.

The test fake was part of the problem

The fake server asserted X-Sandbox-Token == "secret" on every route. That is precisely the
laxness the audit identified as the reason a client/server protocol mismatch could survive a
green suite (the kill()-by-pid bug is the other instance).

It now enforces the same scoping as the real server: host token on management routes, that
sandbox's own token on scoped ones. It immediately caught two of this change's own call sites
while I was writing it, and two existing tests that hand-built a shared Sandbox without the
token a real create() would have returned.

Validation

  • 44 tests pass, including new ones asserting that per-sandbox operations present the
    scoped token, that proxy_headers does not hand out the host one, and that the
    older-server fallback still works.

  • Driven end-to-end against a real sbx-server 0.6.0 in a container — real client, real
    server, not the fake:

    ok  A's token: exec / files round-trip / processes on its own sandbox
    ok  A's token refused for exec, read, write, delete and proxy into B
    ok  A's token refused for create, list, delete-all, and any token recovery
    ok  the host token recovers A's token, and it matches
    

Docs

concepts/sandbox.md §Token scope now describes the three credentials (dedicated token, pool
host token, pooled sandbox token) and what each one's leak actually costs, and the
corresponding "one token per host" entry is removed from Known limitations.


Note

Medium Risk
Changes sandbox authentication and token handling for pooled workloads; behavior is security-tightening with an explicit older-server fallback, but mis-scoped tokens could break pool clients until the server is upgraded.

Overview
Pooled sandboxes no longer send the pool host credential on every API call. The client now keeps a separate per-sandbox capability token (from SandboxPool.create() or recovered on Sandbox.connect via GET /v1/sandboxes/{id}/token) and passes it through _SandboxServer.request/stream as an optional X-Sandbox-Token override for scoped routes, while management calls still use the host token.

proxy_headers and all per-sandbox operations (exec, files, processes, pooled kill) use the narrow token so credentials handed to browsers/WebSockets cannot manage the whole host. Older sbx-server hosts without per-sandbox tokens continue to work by falling back to the host credential when no token is returned.

Docs in concepts/sandbox.md describe the three credential types and leak impact; tests tighten the fake server to enforce the same host vs sandbox token rules as the real server.

Reviewed by Cursor Bugbot for commit 0910a33. Bugbot is set up for automated code reviews on this repo. Configure here.

The pool host's token authorized every sandbox on the host plus its
management routes, and it was the only credential the client had. So every
per-sandbox call -- and `proxy_headers`, which is handed to browsers and
WebSocket clients -- offered authority over the whole host.

sbx-server 0.6.0 mints a capability token per sandbox. This uses it:

- `_SandboxServer.request`/`stream` take an optional `sandbox_token` that
  overrides `X-Sandbox-Token` for one request; the client-level header keeps
  carrying the host credential for management calls.
- `Sandbox` holds its own token and sends it on every scoped call, including
  the `DELETE` that kills a pooled sandbox.
- `SandboxPool.create()` keeps the token from the create response.
- `Sandbox.connect("<host>.<id>")` recovers it via
  `GET /v1/sandboxes/{id}/token`, so reattaching stays stateless.
- `proxy_headers` hands out the sandbox's token, not the host's.

Hosts running an older server return no token and have no recovery route;
those sandboxes fall back to the host credential, so a pool booted before
the server upgrade keeps working. Requires the server change to be deployed
first.

Also makes the test fake enforce the same scoping as the real server --
host token on management routes, the sandbox's own token on scoped ones --
rather than accepting one value everywhere. That laxness is exactly how a
client/server protocol mismatch survives a green suite, and it caught two
of this change's own call sites while writing it.

Validation:
- 44 tests pass, including new ones asserting per-sandbox operations present
  the scoped token, `proxy_headers` does not leak the host one, and the
  older-server fallback still works.
- Driven end-to-end against a real sbx-server 0.6.0 in a container: A's
  token cannot exec, read, write, delete or proxy into B, cannot create,
  list or delete sandboxes, and cannot recover any token; the host token
  recovers A's token and matches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bot-ci-comment

bot-ci-comment Bot commented Sep 8, 2026

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant